Skip to content

[A11y] Connect the accessibility override controls to the app - #10065

Open
victorsanni wants to merge 1 commit into
flutter:masterfrom
victorsanni:a11y-override-controls
Open

victorsanni wants to merge 1 commit into
flutter:masterfrom
victorsanni:a11y-override-controls

Conversation

@victorsanni

@victorsanni victorsanni commented Nov 2, 2026 •

Copy link
Copy Markdown

Part of #9893.

This connects the text scale, bold text, screen reader and high contrast overrides on the Accessibility page to the app. They go through ext.flutter.viewMetricsOverride, which flutter/flutter#192764 added and flutter/flutter#194311 made take effect in the app. Next come moving brightness to this extension, and a fix for the brightness override that is not restored after a hot restart while this page is open.

The new ViewMetricsOverrideClient:

  • Reads the app's overrides once the extension is registered, then follows the replies and the Flutter.ServiceExtensionStateChanged events. It listens to the events itself, because ServiceExtensionManager would turn their JSON value into a string.
  • Starts every write from the overrides that the app last reported and changes only the given metrics, so overrides that other clients set stay.
  • After a hot restart, restores the metrics that DevTools set, with the values that the app last reported for them.

On the page:

  • Text scale sends textScaling: [[1, factor]], which scales text linearly. Until text scale is overridden, the page shows "System default", because 1.0 is an override too. A reset button removes the override.
  • The switches send boldText, accessibleNavigation and highContrast. Turning one off removes the override, so the app goes back to the platform's setting.
  • The controls show what the app reports, including changes from other clients.
  • While the app does not support the extension, for example in a profile build or on an older Flutter version, the controls are disabled and a tooltip says why.

Caveats:

  • Only the implicit view is overridden, because the extension cannot list views yet.
  • The extension replaces a view's whole entry, so a change that another client makes after DevTools last heard from the app, and before its write arrives, is lost. This is likely right after a hot restart if another tool also restores its overrides.
  • On a Flutter version that has Scaffold view metrics overrides flutter#192764 but not Apply view metrics overrides below the framework flutter#194311, the controls are enabled but change nothing.
  • The screen reader switch makes widgets behave as though a screen reader is on. It does not start one.
  • There is no release note, because the page is behind the accessibility experiment flag.

The client tests use a fake of the extension that, like the VM service, never answers a call that is in flight when its isolate exits. They cover following the app, failures and hot restarts, including one during the restore. Controller and widget tests cover what the controls send and show. The devtools_test fakes can now emit extension events and replace the main isolate.

Pre-launch Checklist

General checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read the Flutter Style Guide recently, and have followed its advice.
  • I signed the CLA.
  • I updated/added relevant documentation (doc comments with ///).

Issues checklist

Tests checklist

  • I added new tests to check the change I am making...
  • OR there is a reason for not adding tests, which I explained in the PR description.

AI-tooling checklist

  • I did not use any AI tooling in creating this PR.
  • OR I did use AI tooling, and...
    • I read the AI contributions guidelines and agree to follow them.
    • I reviewed all AI-generated code before opening this PR.
    • I understand and am able to discuss the code in this PR.
    • I have verifed the accuracy of any AI-generated text included in the PR description.
    • I commit to verifying the accuracy of any AI-generated code or text that I upload in response to review comments.

Feature-change checklist

  • This PR does not change the DevTools UI or behavior and...
    • I added the release-notes-not-required label or left a comment requesting the label be added.
  • OR this PR does change the DevTools UI or behavior and...
    • I added an entry to packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md.
    • I included before/after screenshots and/or a GIF demo of the new UI to my PR description.
    • I ran the DevTools app locally to manually verify my changes.

build.yaml badge

If you need help, consider asking for help on Discord.

Adds ViewMetricsOverrideClient, a client for ext.flutter.viewMetricsOverride,
and connects the text scale, bold text, screen reader and high contrast
controls of the Accessibility page to it.
@victorsanni
victorsanni requested a review from a team as a code owner November 2, 2026 18:04
@victorsanni
victorsanni requested review from srawlins and removed request for a team November 2, 2026 18:04

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for the ext.flutter.viewMetricsOverride service extension, enabling users to override accessibility settings (text scale, bold text, screen reader, and high contrast) in debug builds. It introduces ViewMetricsOverrideClient to manage the service extension, handles state restoration across hot restarts, and updates the accessibility controller and UI overrides pane. Feedback focuses on improving error handling: rethrowing and catching service extension failures to notify users, handling background errors with .catchError, adding a defensive check against division by zero in _textScaleFactor, and using a try-finally block to ensure the text scale slider resets properly on failure.

Comment on lines +154 to +171
Future<void> _call(Map<String, String> parameters) async {
final isolateGeneration = _isolateGeneration;
try {
final response = await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(
registrations.viewMetricsOverride,
args: parameters,
);
if (disposed || isolateGeneration != _isolateGeneration) return;
_takeOverridesFrom(response.json?['overrides']);
} catch (e, st) {
_log.warning(
'Failed to call ${registrations.viewMetricsOverride}: $e',
e,
st,
);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] Rethrowing the exception here allows callers (such as updateOverrides) to be aware of service extension failures. Currently, catching and logging the exception silently completes the future, which can lead to silent failures in the UI where a setting appears to change but the backend call actually failed.

Suggested change
Future<void> _call(Map<String, String> parameters) async {
final isolateGeneration = _isolateGeneration;
try {
final response = await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(
registrations.viewMetricsOverride,
args: parameters,
);
if (disposed || isolateGeneration != _isolateGeneration) return;
_takeOverridesFrom(response.json?['overrides']);
} catch (e, st) {
_log.warning(
'Failed to call ${registrations.viewMetricsOverride}: $e',
e,
st,
);
}
}
Future<void> _call(Map<String, String> parameters) async {
final isolateGeneration = _isolateGeneration;
try {
final response = await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(
registrations.viewMetricsOverride,
args: parameters,
);
if (disposed || isolateGeneration != _isolateGeneration) return;
_takeOverridesFrom(response.json?['overrides']);
} catch (e, st) {
_log.warning(
'Failed to call ${registrations.viewMetricsOverride}: $e',
e,
st,
);
rethrow;
}
}
References
  1. Prioritize logic, performance on the UI thread, and architectural consistency. (link)

Comment on lines +244 to +251
void _onAvailabilityChanged() {
final available = _hasExtension?.value ?? false;
if (available == _available.value) return;
_available.value = available;
if (!available) return;
unawaited(_queue(_read));
unawaited(_queue(_restoreMetrics));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] Since background tasks are run using unawaited, any uncaught exceptions from _queue will bubble up to the Zone's uncaught error handler. Adding .catchError ensures these background errors are safely handled and logged without crashing or triggering test failures.

  void _onAvailabilityChanged() {
    final available = _hasExtension?.value ?? false;
    if (available == _available.value) return;
    _available.value = available;
    if (!available) return;
    unawaited(
      _queue(_read).catchError((e, st) {
        _log.warning('Failed to read view metrics overrides: $e', e, st);
      }),
    );
    unawaited(
      _queue(_restoreMetrics).catchError((e, st) {
        _log.warning('Failed to restore view metrics overrides: $e', e, st);
      }),
    );
  }
References
  1. Prioritize logic, performance on the UI thread, and architectural consistency. (link)

Comment on lines +184 to 186
Future<void> _setOverride(String metric, Object? value) async {
await _viewMetricsOverrideClient?.updateOverrides({metric: value});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] Catching the exception here and pushing a notification via notificationService provides a much better user experience. It ensures that if the service extension call fails, the user is notified immediately rather than the UI silently failing to apply the override.

Suggested change
Future<void> _setOverride(String metric, Object? value) async {
await _viewMetricsOverrideClient?.updateOverrides({metric: value});
}
Future<void> _setOverride(String metric, Object? value) async {
try {
await _viewMetricsOverrideClient?.updateOverrides({metric: value});
} catch (e) {
notificationService.push('Failed to set override: $e');
}
}
References
  1. Prioritize logic, performance on the UI thread, and architectural consistency. (link)

Comment on lines +191 to 196
static double? _textScaleFactor(Object? textScaling) {
if (textScaling case [[final num unscaled, final num scaled], ...]) {
return scaled / unscaled;
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] Defensively check if unscaled is zero to avoid division by zero, which would result in double.infinity or double.nan and potentially cause layout or rendering issues in the UI.

Suggested change
static double? _textScaleFactor(Object? textScaling) {
if (textScaling case [[final num unscaled, final num scaled], ...]) {
return scaled / unscaled;
}
return null;
}
static double? _textScaleFactor(Object? textScaling) {
if (textScaling case [[final num unscaled, final num scaled], ...]) {
return unscaled == 0 ? null : scaled / unscaled;
}
return null;
}
References
  1. Prioritize logic, performance on the UI thread, and architectural consistency. (link)

Comment on lines +187 to +192
Future<void> _overrideTextScale(double textScale) async {
await widget.controller.setTextScale(textScale);
if (mounted && _sliderTextScale == textScale) {
setState(() => _sliderTextScale = null);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] Wrapping the call in a try-finally block ensures that _sliderTextScale is reset to null even if the service call fails. This prevents the slider from getting stuck at an invalid/failed value and allows it to snap back to the actual value reported by the app.

  Future<void> _overrideTextScale(double textScale) async {
    try {
      await widget.controller.setTextScale(textScale);
    } finally {
      if (mounted && _sliderTextScale == textScale) {
        setState(() => _sliderTextScale = null);
      }
    }
  }
References
  1. Prioritize logic, performance on the UI thread, and architectural consistency. (link)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant